Skip to content

feat: emit change events for local and remote changes, with corrections marked - #3413

Open
christianhg wants to merge 1 commit into
nextfrom
feat/change-event
Open

christianhg wants to merge 1 commit into
nextfrom
feat/change-event

Conversation

@christianhg

@christianhg christianhg commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

The editor now emits a change event, synchronously, every time its content changes. Each event carries the operations the editor applied to its content (the public Operation type), never the input it received:

editor.on('change', (event) => {
  if (event.origin === 'local') {
    // `event.patches` is the change as Content Lake patches.
    save(event.patches)
  }
})
  • origin: 'local': what a user action did, including everything the action's Behaviors raised. Undo and redo are one action each. Local changes arrive in application order, and one user action can produce several when the editor fixes something mid-action. patches holds the change as Content Lake patches, the same patches its mutation carries, available as soon as the action finishes instead of on the mutation event's debounced schedule.
  • origin: 'local' with correction: true: the editor's own fixes to the content, such as merging adjacent spans that share marks after a paste, or replacing a duplicate _key. A correction follows the change that caused it, with its own operations and patches. Fixes to content the editor received through patches or update value are corrections too. A host that saves every local change's patches saves corrections as well.
  • origin: 'remote': one event per patches message and per update value that changed the content. Remote changes carry no patches.

Loading the initial value emits no change, except a correction when the editor fixes the loaded value, and a change never has an empty operations array. ChangeEvent is exported as a beta type. EditorEmittedEvent gains the change member, so exhaustive switches over event.type gain a case.

A change collector on the engine groups applied operations into units of work: a performed Behavior event, a whole update value, a withRemoteChanges bracket, or a lone apply outside all of them. It records each operation when its apply starts, because the editor finishes a fix before the edit that triggered it, and splits entries by their apply-context frame into edits, corrections and remote operations. A Behavior event whose action groups normalize in between therefore emits edit, correction, edit, and replaying those patches in order gives the editor's value. That case is pinned in event.change.test.tsx, which failed on the collector that grouped by kind.

mutation, patch and the batcher are unchanged here. Removing them is a separate change. Text patches are still derived from the value after normalization, as they are for mutation today, so a normalizer that inserts text inside text the same apply just inserted can replay differently.


Note

Medium Risk
New public event surface and collector ordering affect persistence and sync integrations; existing mutation/patch behavior is unchanged but hosts may now subscribe to overlapping data paths.

Overview
Adds a synchronous change event (editor.on('change')) so hosts can observe every content update with applied **Operation**s—not raw inputs—and optional Content Lake patches for local work.

A new createChangeCollector groups engine applies into units (behavior actions, update value, remote patches brackets, or standalone applies). It emits origin: 'local' (with patches, immediately—not debounced like mutation), origin: 'local' + correction: true for normalization fixes in application order after the triggering edit, and origin: 'remote' (operations only, no patches). withChangeUnit wraps behavior handling, remote sync, and value sync so one user action can yield multiple ordered changes when fixes run mid-action.

ChangeEvent is exported (beta) on EditorEmittedEvent. Patch generation feeds the collector via onPatches; empty-editor placeholder inserts use a placeholder apply-context frame so they do not surface as changes. isPublicOperation moves to types/operation.ts for shared filtering.

Reviewed by Cursor Bugbot for commit 736775b. Bugbot is set up for automated code reviews on this repo. Configure here.

@vercel

vercel Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
portable-text-editor-documentation Ready Ready Preview Oct 25, 2026 6:21pm UTC
portable-text-example-basic Ready Ready Preview Oct 25, 2026 6:21pm UTC
portable-text-playground Ready Ready Preview Oct 25, 2026 6:21pm UTC

Request Review

@changeset-bot

changeset-bot Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 736775b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 15 packages
Name Type
@portabletext/editor Minor
@portabletext/plugin-character-pair-decorator Patch
@portabletext/plugin-decorations Patch
@portabletext/plugin-dnd Patch
@portabletext/plugin-emoji-picker Patch
@portabletext/plugin-input-rule Patch
@portabletext/plugin-list-index Patch
@portabletext/plugin-markdown-shortcuts Patch
@portabletext/plugin-one-line Patch
@portabletext/plugin-paste-link Patch
@portabletext/plugin-sdk-value Patch
@portabletext/plugin-table Patch
@portabletext/plugin-typeahead-picker Patch
@portabletext/plugin-typography Patch
@portabletext/toolbar Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Bundle Stats

✅ No significant changes.

All scenario measurements (7)

🗺️ @portabletext/editor / @portabletext/editor · @portabletext/editor / @portabletext/editor/behaviors · @portabletext/editor / @portabletext/editor/plugins · @portabletext/editor / @portabletext/editor/selectors · @portabletext/editor / @portabletext/editor/traversal · @portabletext/editor / @portabletext/editor/utils · @portabletext/markdown / @portabletext/markdown · Artifacts

Scenario Kind Bundle (raw / gzip) Gzip change Import time Import change
⚪ @portabletext/editor / @portabletext/editor export 1.08 MB / 137.2 KB +590 B, +0.4% 47 ms -1 ms, -1.2%
⚪ @portabletext/editor / @portabletext/editor/behaviors export 4.0 KB / 147 B None 2 ms -0 ms, -1.3%
⚪ @portabletext/editor / @portabletext/editor/plugins export 5.1 KB / 645 B None 5 ms +0 ms, +1.3%
⚪ @portabletext/editor / @portabletext/editor/selectors export 94.6 KB / 9.2 KB None 6 ms +0 ms, +2.1%
⚪ @portabletext/editor / @portabletext/editor/traversal export 43.5 KB / 4.0 KB None 4 ms +0 ms, +3.8%
⚪ @portabletext/editor / @portabletext/editor/utils export 33.7 KB / 4.1 KB None 4 ms +0 ms, +4.8%
⚪ @portabletext/markdown / @portabletext/markdown export 394.4 KB / 62.7 KB None 21 ms +2 ms, +8.2%

Significant means at least 1.0 KB and 1% gzip, or at least 5 ms and 10% import time.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 9a543eb. Configure here.

Comment thread packages/editor/src/editor/change-collector.ts
…ions marked

Hosts had two ways to learn what the editor did to its content: the
per-operation `operation` feed, which has no notion of a user action and
no patches, and `mutation`, which carries patches but only on the
batcher's debounced flush and folds the editor's own normalization fixes
into the user's edit. Neither says "this action did these operations,
as these patches" at the moment the action finishes.

A change collector on the engine now groups applied operations into
`change` events. It records an entry for every operation when its apply
starts, since the `after` listeners of normalization fixes run before
the one of the operation that triggered them, and fills the entry with
the public operation and the local patches patch generation hands it.
Entries split by their apply-context frames: a `normalization` frame
while patching is a correction, a `remote` frame is remote, everything
else is an edit. The collector flushes when the outermost unit of work
returns: the depth-0 `behavior event` the editor machine performs, a
whole value sync (the sync machine's `syncValueCallback`), the
outermost `withRemoteChanges` bracket, or, for an apply outside all of
them, the outermost apply. A flush emits the unit's remote change
first, then its local entries in application order, each run of
consecutive edits or consecutive corrections as its own change. A
Behavior event whose action groups normalize in between therefore emits
edit, correction, edit, and replaying the patches in that order gives
the editor's value. A remote change goes out only when `isEqualValues`
finds the content after the unit differs from the content before its
first remote operation, so a remote `set` of a value the editor already
holds emits nothing. Entries are detached before any event is emitted,
so a listener that applies again starts a new change instead of adding
to one already delivered.

Unit depth is tracked with try/finally, apart from the apply depth the
operation listeners count. An apply that throws leaves the apply depth
raised; the outermost unit resets it on exit, and so does the next
apply that can only be outermost (no open unit and no `normalization`
frame, since only normalization re-enters `apply`). Remote operations
under the `initial-sync` source are dropped, so the first load emits
nothing, and the empty-editor placeholder insert runs under a new
`placeholder` frame and is never reported.

`isPublicOperation` moves from `create-editor.ts` to `types/operation.ts`
so the collector shares the `operation` event's allowlist. `mutation`,
`patch` and the batcher are unchanged.

This branch was successfully deployed

3 active deployments
Preview – portable-text-editor-documentation — 736775b2 Deployed Oct 25, 2026 by vercel[bot]
Preview – portable-text-playground — 736775b2 Deployed Oct 25, 2026 by vercel[bot]
Preview – portable-text-example-basic — 736775b2 Deployed Oct 25, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant